Skip to content

FEAT backend attack API - revisiting attacks & conversations#1419

Open
romanlutz wants to merge 13 commits intoAzure:mainfrom
romanlutz:romanlutz/backend-attack-api
Open

FEAT backend attack API - revisiting attacks & conversations#1419
romanlutz wants to merge 13 commits intoAzure:mainfrom
romanlutz:romanlutz/backend-attack-api

Conversation

@romanlutz
Copy link
Contributor

Summary

This PR builds out the backend API from a basic scaffold into a functional attack-centric API that supports
multi-conversation attacks, conversation branching, message sending with target dispatch, and richer memory/model
support. It spans 3 logical commits stacked as a single branch.

Changes

  1. Initialization & Auth (0d10970) - note that there's overlap with MAINT Migrate Azure Cognitive Services from API key to Entra ID authentication #1404 and the differences will be resolved once that's merged - the main things to review are in sections 2 and 3
  • run_initializers_async: New public function in pyrit.setup to run initializers without re-initializing memory —
    enables pyrit_backend and FrontendCore to separate memory setup from initializer execution.
  • Entra (Azure AD) auth for AIRTInitializer: Removes API key requirements (_CHAT_KEY, _CHAT_KEY2,
    _CONTENT_SAFETY_API_KEY), switches to DefaultAzureCredential via get_azure_openai_auth / get_azure_token_provider.
  • --config-file flag for pyrit_backend CLI — loads configuration from a YAML file via ConfigurationLoader.
  • FrontendCore.run_initializers_async() — extracted initializer resolution from run_scenario_async into a reusable
    method; pyrit_backend now uses FrontendCore directly instead of duplicating logic.
  • New target config: azure_openai_gpt5_responses_high_reasoning with extra_body_parameters support in TargetConfig.
  1. Memory & Models (a9993ab)
  • ConversationStats (new model): Lightweight dataclass for aggregate conversation stats (message count, preview,
    labels, timestamp) — avoids loading full message pieces for list views.
  • attack_result_id field on AttackResult: Database-assigned primary key exposed to callers.
  • get_conversation_stats(): New abstract method on MemoryInterface with SQLite implementation — efficient SQL
    aggregation for conversation summaries.
  • Rename attack_class → attack_type and converter_classes → converter_types across memory interface, SQLite
    implementation, and all callers.
  • duplicate_messages(): Renamed from _duplicate_conversation() and made public for use by the attack service's
    branching logic.
  • targeted_harm_categories field on PromptMemoryEntry.
  • prompt_metadata support added to OpenAIImageTarget, OpenAIVideoTarget, and OpenAIResponseTarget.
  1. Backend API (3cfc605)
  • Attack CRUD refactored: Routes keyed by attack_result_id (not conversation_id), AttackSummary includes TargetInfo
    (type, endpoint, model), related_conversation_ids.
  • Conversation management:
    - GET /{id}/conversations — list all conversations for an attack
    - POST /{id}/conversations — create a related conversation (with optional branching from source_conversation_id +
    cutoff_index)
    - POST /{id}/change-main-conversation — swap the primary conversation
    - GET /{id}/messages?conversation_id= — get messages for a specific conversation
  • Message sending (POST /{id}/messages): Accepts target_registry_name to resolve the target at send time,
    target_conversation_id to route messages to a specific conversation, and labels for per-message labeling. Includes
    full traceback in 500 error responses for debugging.
  • Attack mappers: attack_result_to_summary now takes ConversationStats instead of full pieces;
    pyrit_messages_to_dto_async generates signed blob URLs for non-text content and extracts
    original_filename/converted_filename.
  • Target service: get_target_by_name for registry-based target lookup.
  • Version endpoint: Returns database info alongside version.
Area Files
Backend routes/models/services routes/attacks.py, routes/targets.py, routes/version.py, models/attacks.py, models/targets.py, services/attack_service.py, services/target_service.py, mappers/attack_mappers.py, mappers/target_mappers.py, main.py
Memory layer memory_interface.py, sqlite_memory.py, azure_sql_memory.py, memory_models.py
Models attack_result.py, conversation_stats.py (new), __init__.py
Setup & CLI initialization.py, __init__.py, airt.py, airt_targets.py, frontend_core.py, pyrit_backend.py
Prompt targets openai_target.py, openai_image_target.py, openai_video_target.py, openai_response_target.py, openai_realtime_target.py
Tests (13 files) Unit tests for all backend services, routes, mappers, memory, CLI, setup, and targets

romanlutz and others added 4 commits February 28, 2026 14:49
- Add run_initializers_async to pyrit.setup for programmatic initialization
- Switch AIRTInitializer to Entra (Azure AD) auth, removing API key requirements
- Add --config-file flag to pyrit_backend CLI
- Use PyRIT configuration loader in FrontendCore and pyrit_backend
- Update AIRTTargetInitializer with new target types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add conversation_stats model and attack_result extensions
- Add get_attack_results with filtering by harm categories, labels,
  attack type, and converter types to memory interface
- Implement SQLite-specific JSON filtering for attack results
- Add memory_models field for targeted_harm_categories
- Add prompt_metadata support to openai image/video/response targets
- Fix missing return statements in SQLite harm_category and label filters

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add attack CRUD routes with conversation management
- Add message sending with target dispatch and response handling
- Add attack mappers for domain-to-DTO conversion with signed blob URLs
- Add attack service with video remix support and piece persistence
- Expand target service and routes with registry-based target management
- Add version endpoint with database info

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 1, 2026 13:28
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Builds out the PyRIT backend from a scaffold into an attack-centric API, adding multi-conversation attack support, conversation summarization, target registry interactions, and memory-layer enhancements (including Entra-auth-related initializer updates).

Changes:

  • Adds attack/conversation backend endpoints keyed by attack_result_id, plus message sending that dispatches to targets by registry name.
  • Introduces ConversationStats and new memory APIs to efficiently summarize conversations without loading full message pieces.
  • Updates setup/CLI and initializers (incl. run_initializers_async + config-file support) and expands OpenAI target/mapping behavior for richer metadata/media handling.

Reviewed changes

Copilot reviewed 43 out of 43 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/unit/target/test_video_target.py Adds regression test for single-turn enforcement on video target requests.
tests/unit/target/test_image_target.py Adds async regression test for single-turn enforcement on image target requests.
tests/unit/setup/test_airt_targets_initializer.py Adds test coverage for GPT-5 high-reasoning Responses target extra body parameters.
tests/unit/setup/test_airt_initializer.py Updates AIRT initializer tests to reflect Entra/token auth (no API key env vars).
tests/unit/memory/test_sqlite_memory.py Adds unit tests for get_conversation_stats aggregation behavior.
tests/unit/memory/memory_interface/test_interface_attack_results.py Expands tests for attack result dedupe + update semantics and renames attack/converter filters.
tests/unit/cli/test_pyrit_backend.py Adds CLI tests for pyrit_backend arg parsing and initialization wiring.
tests/unit/cli/test_frontend_core.py Updates FrontendCore expectations + patches to new initialization flow and defaults.
tests/unit/backend/test_target_service.py Updates tests for renamed target fields (target_registry_name).
tests/unit/backend/test_mappers.py Adds extensive mapper tests for media handling, blob URL signing/fetching, and summary mapping changes.
tests/unit/backend/test_main.py Updates lifespan tests to reflect CLI-driven initialization instead of in-app initialization.
tests/unit/backend/test_api_routes.py Updates route tests for new endpoints, request/response shapes, and renamed filters/fields.
pyrit/setup/initializers/airt_targets.py Adds target config extra_kwargs and new GPT-5 high-reasoning Responses target; adjusts video target config naming/env vars.
pyrit/setup/initializers/airt.py Switches AIRTInitializer to Entra/token auth and updates scorer setup accordingly.
pyrit/setup/initialization.py Adds run_initializers_async helper to run initializers without reinitializing memory.
pyrit/setup/init.py Re-exports run_initializers_async.
pyrit/prompt_target/openai/openai_video_target.py Adds validation to enforce single-turn conversations for video target usage.
pyrit/prompt_target/openai/openai_target.py Refactors OpenAITarget base inheritance/initialization behavior.
pyrit/prompt_target/openai/openai_response_target.py Includes extra_body_parameters in target identifiers for richer provenance.
pyrit/prompt_target/openai/openai_realtime_target.py Adjusts realtime target inheritance to ensure chat-target semantics.
pyrit/prompt_target/openai/openai_image_target.py Adds validation to enforce single-turn conversations for image target usage.
pyrit/models/conversation_stats.py Introduces ConversationStats dataclass for lightweight conversation summaries.
pyrit/models/attack_result.py Adds attack_result_id to expose persisted identifier to callers.
pyrit/models/init.py Exports ConversationStats.
pyrit/memory/sqlite_memory.py Implements get_conversation_stats, improves update semantics, and renames attack/converter filter helpers.
pyrit/memory/memory_models.py Populates attack_result_id when constructing AttackResult from DB entry.
pyrit/memory/memory_interface.py Adds get_conversation_stats, exposes duplicate_messages, and updates attack/converter filter APIs + update helpers.
pyrit/memory/azure_sql_memory.py Mirrors SQLite changes for attack/converter filters and implements get_conversation_stats.
pyrit/cli/pyrit_backend.py Refactors backend CLI to use FrontendCore + adds --config-file.
pyrit/cli/frontend_core.py Splits “initialize” vs “run initializers” behavior and reuses run_initializers_async.
pyrit/backend/services/target_service.py Renames API surface to target_registry_name and adjusts pagination cursor semantics.
pyrit/backend/services/attack_service.py Major refactor: attack_result_id-keyed operations, multi-conversation support, branching, message routing, and async DTO mapping.
pyrit/backend/routes/version.py Adds optional database backend info to version response.
pyrit/backend/routes/targets.py Renames target route params to target_registry_name.
pyrit/backend/routes/attacks.py Adds conversation management endpoints and refactors message endpoints to support per-conversation routing.
pyrit/backend/models/targets.py Updates DTO fields (registry naming) and adds supports_multiturn_chat.
pyrit/backend/models/attacks.py Refactors attack DTOs for attack_result_id, target info nesting, conversation endpoints, and message routing fields.
pyrit/backend/models/init.py Updates exported backend models to reflect renamed/removed DTOs.
pyrit/backend/mappers/target_mappers.py Updates target mapping to registry naming and adds multiturn capability detection.
pyrit/backend/mappers/attack_mappers.py Refactors summary mapping to use ConversationStats and adds media/blob URL signing/fetching logic + async message DTO mapping.
pyrit/backend/mappers/init.py Exports pyrit_messages_to_dto_async instead of sync variant.
pyrit/backend/main.py Removes implicit initialization in lifespan; warns when started without CLI initialization.

Comment on lines +16 to +22
# Ensure emoji and other Unicode characters don't crash on Windows consoles
# that use legacy encodings like cp1252.
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
sys.stderr.reconfigure(errors="replace") # type: ignore[union-attr]

import uvicorn

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import uvicorn occurs after executable statements (sys.stdout.reconfigure / sys.stderr.reconfigure). With Ruff E402 enabled for pyrit/**, this is likely to fail linting as a non-top-level import. Move the uvicorn import up with the other imports (and keep the reconfigure calls after all imports), or wrap the reconfigure logic inside main() so imports remain at the top of the module.

Copilot uses AI. Check for mistakes.
Comment on lines +409 to +417
tb = traceback.format_exception(type(e), e, e.__traceback__)
# Include the root cause if chained
cause = e.__cause__
if cause:
tb += traceback.format_exception(type(cause), cause, cause.__traceback__)
detail = "".join(tb)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add message: {str(e)}",
detail=detail,
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500 error path returns a full Python traceback to the API caller. This can leak sensitive details (file paths, environment variables, internal URLs, tokens) and makes it hard to safely run the backend outside of a trusted dev environment. Gate this behavior behind an explicit debug flag (e.g., DEV_MODE / config), and otherwise return a generic error message while logging the traceback server-side.

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +72
return value.startswith("https://") and ".blob.core.windows.net/" in value


Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_is_azure_blob_url currently checks for the substring .blob.core.windows.net/ anywhere in the URL. This can misclassify non-Azure URLs (e.g., a malicious host with that string in the path) and trigger server-side requests / signing attempts, creating an SSRF surface area. Parse the URL and validate that netloc ends with blob.core.windows.net (and ideally enforce an allowlist of expected storage accounts/containers) before treating it as an Azure Blob URL.

Suggested change
return value.startswith("https://") and ".blob.core.windows.net/" in value
parsed = urlparse(value)
if parsed.scheme != "https":
return False
if not parsed.netloc:
return False
# Extract hostname from possible "user@host:port" netloc forms
host = parsed.netloc.split("@")[-1].split(":")[0]
if not host.endswith(".blob.core.windows.net"):
return False
# Ensure there is a storage account name prefix before ".blob.core.windows.net"
account_name = host.split(".")[0]
return bool(account_name)

Copilot uses AI. Check for mistakes.
Comment on lines +1291 to +1295
from contextlib import closing

with closing(self.get_session()) as session:
from sqlalchemy.exc import SQLAlchemyError

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_attack_results_to_memory introduces local imports for closing and SQLAlchemyError even though closing is already imported at module scope. This adds noise and conflicts with the project's "imports at the top of the file" convention. Please remove the redundant local import and move SQLAlchemyError to the module imports (or reuse an existing top-level import) so this method contains only logic.

Copilot uses AI. Check for mistakes.
Comment on lines +719 to +745
@staticmethod
def _get_last_message_preview(pieces: Sequence[PromptMemoryEntry]) -> Optional[str]:
"""Return a truncated preview of the last message piece's text."""
if not pieces:
return None
last = max(pieces, key=lambda p: p.sequence)
text = last.converted_value or ""
return text[:100] + "..." if len(text) > 100 else text

@staticmethod
def _count_messages(pieces: Sequence[PromptMemoryEntry]) -> int:
"""
Count distinct messages (by sequence number) in a list of pieces.

Returns:
The number of unique sequence values.
"""
return len(set(p.sequence for p in pieces))

@staticmethod
def _get_earliest_timestamp(pieces: Sequence[PromptMemoryEntry]) -> Optional[datetime]:
"""Return the earliest timestamp from a list of message pieces."""
if not pieces:
return None
timestamps: List[datetime] = [p.timestamp for p in pieces if p.timestamp is not None]
return min(timestamps) if timestamps else None

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper methods _get_last_message_preview, _count_messages, and _get_earliest_timestamp appear to be unused in this module (no call sites besides their definitions). Keeping unused code makes future refactors harder and increases maintenance cost; consider removing them or wiring them into the conversation-summary logic if they are intended as a fallback.

Suggested change
@staticmethod
def _get_last_message_preview(pieces: Sequence[PromptMemoryEntry]) -> Optional[str]:
"""Return a truncated preview of the last message piece's text."""
if not pieces:
return None
last = max(pieces, key=lambda p: p.sequence)
text = last.converted_value or ""
return text[:100] + "..." if len(text) > 100 else text
@staticmethod
def _count_messages(pieces: Sequence[PromptMemoryEntry]) -> int:
"""
Count distinct messages (by sequence number) in a list of pieces.
Returns:
The number of unique sequence values.
"""
return len(set(p.sequence for p in pieces))
@staticmethod
def _get_earliest_timestamp(pieces: Sequence[PromptMemoryEntry]) -> Optional[datetime]:
"""Return the earliest timestamp from a list of message pieces."""
if not pieces:
return None
timestamps: List[datetime] = [p.timestamp for p in pieces if p.timestamp is not None]
return min(timestamps) if timestamps else None
# (Reserved for future conversation-info helpers; currently unused.)

Copilot uses AI. Check for mistakes.
@romanlutz romanlutz changed the title FEAT backend attack api FEAT backend attack API - revisiting attacks & conversations Mar 1, 2026
Copilot AI review requested due to automatic review settings March 3, 2026 00:53
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.

Comment on lines 1159 to 1160
results = sqlite_instance.get_attack_results(attack_type="CrescendoAttack")
assert len(results) == 2
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoryInterface.get_attack_results() currently takes attack_class/converter_classes (see pyrit/memory/memory_interface.py), so calling it with attack_type= will raise TypeError. Either update the memory interface + implementations to the new parameter name, or keep the tests using attack_class= to match the current API.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 3, 2026 05:01
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.

Comment on lines +245 to 253
messages = await service.get_conversation_messages_async(
attack_result_id=attack_result_id,
conversation_id=conversation_id,
)
if not messages:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Attack '{conversation_id}' not found",
detail=f"Attack '{attack_result_id}' not found",
)
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_conversation_messages can surface a ValueError from the service when conversation_id is not part of the attack. Right now that exception is unhandled (will return 500) and the 404 branch always reports “Attack not found”, which is misleading when the attack exists but the conversation is invalid. Consider catching ValueError here and returning 400, and returning a clearer 404 only when the attack itself is missing.

Copilot uses AI. Check for mistakes.
Comment on lines 409 to 418
tb = traceback.format_exception(type(e), e, e.__traceback__)
# Include the root cause if chained
cause = e.__cause__
if cause:
tb += traceback.format_exception(type(cause), cause, cause.__traceback__)
detail = "".join(tb)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add message: {str(e)}",
detail=detail,
) from e
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 500 handler for POST /{attack_result_id}/messages returns the full Python traceback in the HTTP response body. This can leak internal paths, environment details, and secrets to clients. Please gate this behavior behind an explicit debug/development flag and return a generic error message in production (while still logging the traceback server-side).

Copilot uses AI. Check for mistakes.
Comment on lines 1193 to 1201
def test_get_attack_results_converter_types_none_returns_all(sqlite_instance: MemoryInterface):
"""Test that converter_types=None (omitted) returns all attacks unfiltered."""
ar1 = _make_attack_result_with_identifier("conv_1", "Attack", ["Base64Converter"])
ar2 = _make_attack_result_with_identifier("conv_2", "Attack") # No converters (None)
ar3 = create_attack_result("conv_3", 3) # No identifier at all
sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2, ar3])

results = sqlite_instance.get_attack_results(converter_classes=None)
results = sqlite_instance.get_attack_results(converter_types=None)
assert len(results) == 3
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests use converter_types as the keyword for converter filtering, but MemoryInterface.get_attack_results currently expects converter_classes. Either update the tests to use the implemented keyword, or rename/alias the memory interface parameter (and all implementations) so the call site matches.

Copilot uses AI. Check for mistakes.
Comment on lines 1298 to 1338
def test_get_unique_attack_type_names_empty(sqlite_instance: MemoryInterface):
"""Test that no attacks returns empty list."""
result = sqlite_instance.get_unique_attack_class_names()
result = sqlite_instance.get_unique_attack_type_names()
assert result == []


def test_get_unique_attack_class_names_sorted_unique(sqlite_instance: MemoryInterface):
"""Test that unique class names are returned sorted, with duplicates removed."""
def test_get_unique_attack_type_names_sorted_unique(sqlite_instance: MemoryInterface):
"""Test that unique type names are returned sorted, with duplicates removed."""
ar1 = _make_attack_result_with_identifier("conv_1", "CrescendoAttack")
ar2 = _make_attack_result_with_identifier("conv_2", "ManualAttack")
ar3 = _make_attack_result_with_identifier("conv_3", "CrescendoAttack")
sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2, ar3])

result = sqlite_instance.get_unique_attack_class_names()
result = sqlite_instance.get_unique_attack_type_names()
assert result == ["CrescendoAttack", "ManualAttack"]


def test_get_unique_attack_class_names_skips_empty_identifier(sqlite_instance: MemoryInterface):
def test_get_unique_attack_type_names_skips_empty_identifier(sqlite_instance: MemoryInterface):
"""Test that attacks with empty attack_identifier (no class_name) are excluded."""
ar_no_id = create_attack_result("conv_1", 1) # No attack_identifier → stored as {}
ar_with_id = _make_attack_result_with_identifier("conv_2", "CrescendoAttack")
sqlite_instance.add_attack_results_to_memory(attack_results=[ar_no_id, ar_with_id])

result = sqlite_instance.get_unique_attack_class_names()
result = sqlite_instance.get_unique_attack_type_names()
assert result == ["CrescendoAttack"]


def test_get_unique_converter_class_names_empty(sqlite_instance: MemoryInterface):
def test_get_unique_converter_type_names_empty(sqlite_instance: MemoryInterface):
"""Test that no attacks returns empty list."""
result = sqlite_instance.get_unique_converter_class_names()
result = sqlite_instance.get_unique_converter_type_names()
assert result == []


def test_get_unique_converter_class_names_sorted_unique(sqlite_instance: MemoryInterface):
"""Test that unique converter class names are returned sorted, with duplicates removed."""
def test_get_unique_converter_type_names_sorted_unique(sqlite_instance: MemoryInterface):
"""Test that unique converter type names are returned sorted, with duplicates removed."""
ar1 = _make_attack_result_with_identifier("conv_1", "Attack", ["Base64Converter", "ROT13Converter"])
ar2 = _make_attack_result_with_identifier("conv_2", "Attack", ["Base64Converter"])
sqlite_instance.add_attack_results_to_memory(attack_results=[ar1, ar2])

result = sqlite_instance.get_unique_converter_class_names()
result = sqlite_instance.get_unique_converter_type_names()
assert result == ["Base64Converter", "ROT13Converter"]
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests reference get_unique_attack_type_names() / get_unique_converter_type_names(), but the memory interface currently exposes get_unique_attack_class_names() / get_unique_converter_class_names(). This will fail at runtime unless the interface was renamed accordingly. Please update the tests to match the implemented method names, or implement the renamed methods (optionally keeping the old names as aliases for compatibility).

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +255
# Verify the conversation belongs to this attack
ar = results[0]
allowed_related_ids = {
ref.conversation_id for ref in ar.related_conversations if ref.conversation_type == ConversationType.PRUNED
}
all_conv_ids = {ar.conversation_id} | allowed_related_ids
if conversation_id not in all_conv_ids:
raise ValueError(f"Conversation '{conversation_id}' is not part of attack '{attack_result_id}'")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_conversation_messages_async currently only treats PRUNED related conversations as belonging to the attack. After change_main_conversation_async, the old main conversation is moved to ADVERSARIAL related conversations, which would then be rejected here. Consider allowing all related conversation IDs (or at least PRUNED + ADVERSARIAL + SCORE) when validating membership so users can still fetch messages for any conversation attached to the attack.

Copilot uses AI. Check for mistakes.
Comment on lines +599 to 613
# Use the explicitly-provided conversation_id for message storage
msg_conversation_id = request.target_conversation_id

# The frontend must supply the target registry name so the backend
# stays stateless — no reverse lookups, no in-memory mapping.
target_registry_name = request.target_registry_name
if request.send and not target_registry_name:
raise ValueError("target_registry_name is required when send=True")

# Get existing messages to determine sequence.
# NOTE: This read-then-write is not atomic (TOCTOU). Fine for the
# current single-user UI, but would need a DB-level sequence
# generator or optimistic locking if concurrent writes are supported.
existing = self._memory.get_message_pieces(conversation_id=conversation_id)
existing = self._memory.get_message_pieces(conversation_id=msg_conversation_id)
sequence = max((p.sequence for p in existing), default=-1) + 1
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_message_async uses request.target_conversation_id as the conversation to write to, but it never validates that this conversation_id belongs to the attack (main or related). As-is, a client could write messages into an unrelated conversation_id and still update the attack metadata, potentially corrupting data. Please validate msg_conversation_id against the attack's allowed conversation IDs before reading/writing pieces and before calling _send/_store.

Copilot uses AI. Check for mistakes.
update_fields={
"conversation_id": target_conv_id,
"pruned_conversation_ids": updated_pruned if updated_pruned else None,
"adversarial_chat_conversation_ids": updated_adversarial if updated_adversarial else None,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change_main_conversation_async updates conversation_id and related-conversation columns, but it doesn't update the attack metadata (e.g., updated_at). This can cause the attack list ordering and “last updated” timestamp to be stale after a main-conversation swap. Consider persisting an updated attack_metadata.updated_at alongside the other updates.

Suggested change
"adversarial_chat_conversation_ids": updated_adversarial if updated_adversarial else None,
"adversarial_chat_conversation_ids": updated_adversarial if updated_adversarial else None,
"updated_at": datetime.now(timezone.utc),

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 3, 2026 05:33
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.

Comment on lines 225 to +228
await attack_service.list_attacks_async(converter_types=[])

call_kwargs = mock_memory.get_attack_results.call_args[1]
assert call_kwargs["converter_classes"] == []
assert call_kwargs["converter_types"] == []
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions check for converter_types in the kwargs passed to mock_memory.get_attack_results, but the service currently forwards this filter as converter_classes=... (matching MemoryInterface.get_attack_results). As written, this test will fail. Update the expected kwarg key to converter_classes (or rename the memory-layer parameter and the service call consistently if the interface is meant to change).

Copilot uses AI. Check for mistakes.
Comment on lines +265 to +267
# Verify converter_types was forwarded to the memory layer
call_kwargs = mock_memory.get_attack_results.call_args[1]
assert call_kwargs["converter_classes"] == ["Base64Converter", "ROT13Converter"]
assert call_kwargs["converter_types"] == ["Base64Converter", "ROT13Converter"]
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts the call forwarded converter_types, but AttackService.list_attacks_async() calls the memory layer using converter_classes=converter_types. This mismatch will cause the test to fail; assert against converter_classes (or update the memory interface + service together if the memory API is being renamed).

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +250
allowed_related_ids = {
ref.conversation_id for ref in ar.related_conversations if ref.conversation_type == ConversationType.PRUNED
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conversation membership validation here only considers related conversations of type PRUNED. However, the API and service also manage other related conversation types (e.g., ADVERSARIAL via change_main_conversation_async). As a result, GET /attacks/{attack_result_id}/messages will incorrectly reject valid related conversations. Include all conversation IDs from ar.related_conversations (or explicitly document/implement which types are supported) when validating conversation_id.

Suggested change
allowed_related_ids = {
ref.conversation_id for ref in ar.related_conversations if ref.conversation_type == ConversationType.PRUNED
}
allowed_related_ids = {ref.conversation_id for ref in ar.related_conversations}

Copilot uses AI. Check for mistakes.
Comment on lines +393 to +397
# Collect all conversation IDs (main + PRUNED related) and fetch stats in one query.
pruned_related_ids = [
ref.conversation_id for ref in ar.related_conversations if ref.conversation_type == ConversationType.PRUNED
]
all_conv_ids = [ar.conversation_id] + pruned_related_ids
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_conversations_async only returns the main conversation plus PRUNED related conversations. This will omit other related conversation types already tracked on AttackResult (e.g., adversarial chat or scoring conversations), so the endpoint won’t actually “list all conversations for an attack”. Consider including all ar.related_conversations in all_conv_ids (and in the response), or explicitly filtering with a documented rationale if only certain types should be exposed.

Suggested change
# Collect all conversation IDs (main + PRUNED related) and fetch stats in one query.
pruned_related_ids = [
ref.conversation_id for ref in ar.related_conversations if ref.conversation_type == ConversationType.PRUNED
]
all_conv_ids = [ar.conversation_id] + pruned_related_ids
# Collect all conversation IDs (main + all related) and fetch stats in one query.
related_ids = [
ref.conversation_id
for ref in ar.related_conversations
if ref.conversation_id is not None
]
# Use dict.fromkeys to preserve order while removing duplicates.
all_conv_ids = list(dict.fromkeys([ar.conversation_id] + related_ids))

Copilot uses AI. Check for mistakes.
Comment on lines +783 to +790
for piece in request.pieces:
if piece.data_type == "text" or piece.data_type == "error":
continue

# Already a remote URL (e.g. signed blob URL from a remix) — keep as-is
if piece.original_value.startswith(("http://", "https://")):
if piece.converted_value is None:
piece.converted_value = piece.original_value
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_persist_base64_pieces currently persists any piece that isn’t text or error, including valid non-file PromptDataTypes like reasoning, url, function_call, etc. Those values are not base64-encoded files and will either be corrupted or cause decode/write failures. Restrict persistence to the *_path/binary_path types (and leave other non-text types untouched).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants